Skip to content

feat(scripts): script bundle import/export (#3245) - #3276

Merged
ToddHebebrand merged 2 commits into
mainfrom
feat/3245-script-bundle-import-export
Aug 8, 2026
Merged

feat(scripts): script bundle import/export (#3245)#3276
ToddHebebrand merged 2 commits into
mainfrom
feat/3245-script-bundle-import-export

Conversation

@ToddHebebrand

Copy link
Copy Markdown
Collaborator

Summary

Implements #3245 (RMM-migration epic #3249): a versioned JSON script bundle format with export, preview, and import under /scripts/bundle/*, web UI on the Scripts page, and docs — so a script library can move between Breeze instances (or in from another RMM) carrying parameters, categories, tags, timeouts, run-as levels, and exit-code severity mappings instead of being pasted in one script at a time.

Plan: docs/superpowers/plans/open/2026-08-08-script-bundle-import-export.md · Spec: docs/superpowers/specs/onboarding-signup/2026-08-08-script-bundle-import-export-design.md

Security posture (the point of this PR)

A bundle is untrusted input whose contents run as SYSTEM on customer endpoints:

  • Intake hardening (services/scriptBundle/schema.ts): the schema has no tenancy or trust fields — isSystem, id, orgId, partnerId, createdBy in an uploaded file are stripped and never read. parameters is bounded at intake (64KB serialized + depth 8) instead of inheriting the route schema's z.any() (whose 64KB cap is execute-time only). An all-null exitCodeSeverityMapping (never-alert) is rejected. Unknown bundleVersion is rejected, never best-effort parsed. Caps: 200 scripts/bundle, 256KB/content, 20MB body (new bodyLimit carve-out).
  • isSystem can never come from a bundle, at any caller scope — stricter than POST /scripts. The importer never passes requestedIsSystem, and the clamp lives in the shared service (below), not the route.
  • Partner-wide gate ([API][Security] POST /scripts allows partner-wide script creation without canManagePartnerWidePolicies — 'selected'-access users can push SYSTEM-level code to every org #3262/fix(api): gate partner-wide script writes on canManagePartnerWidePolicies (#3262) #3263 follow-through): the fix(api): gate partner-wide script writes on canManagePartnerWidePolicies (#3262) #3263 review flagged that canManagePartnerWidePolicies lived only in route handlers with no service-layer chokepoint, naming this importer as the likeliest path to reopen the privilege escalation. This PR extracts the POST /scripts tenancy resolution + capability gate + isSystem clamp into services/scriptWrite.ts and routes BOTH POST /scripts and the bundle importer through it. availability: 'partner' requires canManagePartnerWidePolicies(auth) and sets partnerId = auth.partnerId / orgId = NULL; the import/preview routes additionally fail fast with PARTNER_WIDE_WRITE_DENIED_MESSAGE (403) before touching any entry. Default availability is 'org'.
  • No automations, schedules, or triggers are expressible in a v1 bundle, and import never executes anything.
  • Audit: every imported/renamed/versioned script gets an individual script.bundle.import audit event tagged with the bundle's sha256, mode, and availability, so a later abuse finding traces to the import that introduced it. Imported rows are ordinary scripts rows, so the existing abuse-signal sweep covers them by construction.
  • Export emits no tenancy identifiers and no isSystem — a round-trip cannot launder system scripts back in.

What's included

  • API: GET /scripts/bundle/export?ids=…, POST /scripts/bundle/preview, POST /scripts/bundle/import (mode: skip | rename | new-version), mounted before the /:id routes; same scope/permission/MFA gating as the script write routes. Tag resolution by name in the target scope (reuse existing, create missing); new-version snapshots the previous content into script_versions before bumping. Per-entry failures are recorded and the rest proceed.
  • Web: Export multi-select → download .json; import accepts a .json bundle, loose .ps1/.sh/.py/.bat files, or a folder (converted client-side — the server keeps one JSON intake). Preview table with conflict statuses, mode selector, partner-wide option gated on user.canManagePartnerWide, commit via runAction, and an explicit "scripts run as SYSTEM — trusted sources only" warning. i18n for all seven locales.
  • Docs: bundle section in features/scripts.mdx (format, caps, unsigned-trust model).
  • No new tables / no migrations — RLS, cascade, and export-policy registries are untouched by construction.

Tests

  • services/scriptBundle/index.test.ts (20): schema hardening (strip/bounds/all-null mapping/version), import modes, tag resolution, tenancy-ignore, isSystem ignored even for a system-scope caller, service-level partner-gate denial, export cleanliness + schema round-trip.
  • services/scriptWrite.test.ts (10): scope resolution matrix incl. the [API][Security] POST /scripts allows partner-wide script creation without canManagePartnerWidePolicies — 'selected'-access users can push SYSTEM-level code to every org #3262 gate; isSystem clamp incl. the no-option bundle path.
  • routes/scriptBundle.test.ts (11): 403 for a non-capability partner user importing availability:'partner' (nothing written), org-scope 403, partner-wide row shape for a full-partner admin, default-'org', isSystem/tenancy strip over HTTP, unknown version 400, per-script audit with sha256, export shape, preview gate.
  • middleware/bodyLimit.test.ts: 20MB carve-out.
  • Web: lib/scriptBundle.test.ts (8) for loose-file conversion; locale parity + translation coverage green.

Local results: API affected suite 98 passed / 2 pre-existing skips (single-fork); web 71 passed; tsc --noEmit clean for both apps/api and apps/web; eslint clean on changed files.

Note: routes/scripts.execute-schema.test.ts crashes at import in this dev environment on clean main too (Node 20 vs required 22 toolchain issue) — unrelated to this change; CI runs it normally.

Known gaps / follow-ups

  • apps/docs/.../migration/toolkit.mdx (Recipe 6 replacement, plan Task 8) lives on the unmerged docs branch (PR docs(migration): RMM-to-Breeze migration guides #3250), not main — it should be updated there once these routes land.
  • Category travels as the scripts.category varchar (by name); the script_categories hierarchy table is not populated by import (the real scripts routes don't use it either — the hierarchical library routes are still mock-backed).

Closes #3245

🤖 Generated with Claude Code

Adds a versioned JSON script-bundle format with export, preview, and
import under /scripts/bundle/*, plus web UI and docs, so a script
library can move between Breeze instances (or in from another RMM)
with its metadata intact.

API:
- services/scriptBundle/schema.ts: v1 bundle Zod schema. Untrusted-input
  hardening at intake: no tenancy/trust fields (unknown keys stripped, so
  isSystem/orgId/partnerId/id/createdBy in an uploaded file cannot carry
  through), parameters bounded by size (64KB) and depth (8) instead of the
  route schema's z.any(), all-null exitCodeSeverityMapping rejected,
  unknown bundleVersion rejected, caps on scripts-per-bundle (200) and
  content size (256KB).
- services/scriptWrite.ts: NEW service-layer chokepoint for script
  creation, extracted from POST /scripts. Carries the tenancy resolution,
  the #3262 partner-wide capability gate (canManagePartnerWidePolicies),
  and the isSystem clamp; both POST /scripts and the bundle importer
  write through it so the two intakes cannot diverge (addresses the
  #3263 review finding that the gate had no service-layer chokepoint).
- services/scriptBundle/index.ts: exportBundle (no tenancy identifiers,
  isSystem always absent), previewBundle (new/name-conflict, no writes),
  importBundle (skip/rename/new-version modes, tag resolution by name,
  per-entry failure isolation). The importer never passes
  requestedIsSystem, so a bundle can never create a system script at any
  caller scope - stricter than POST /scripts.
- routes/scriptBundle.ts: GET /scripts/bundle/export, POST
  /scripts/bundle/preview, POST /scripts/bundle/import. Same
  scope/permission/MFA gating as the script write routes; route-level
  fail-fast on availability:'partner' (only partner scope with the
  capability); availability defaults to 'org'; every imported script is
  audited individually with the bundle's sha256.
- middleware/bodyLimit.ts: 20MB carve-out for bundle import/preview.

Web:
- lib/scriptBundle.ts: client-side conversion of loose .ps1/.sh/.py/.bat
  files (or a folder) into a bundle - the server keeps a single JSON
  intake path (same split as the #3242 CSV handling).
- components/scripts/ScriptBundleImport.tsx: export multi-select ->
  download .json; import with preview table, conflict-mode selector,
  partner-wide option gated on canManagePartnerWide, runAction commit,
  and an explicit scripts-run-as-SYSTEM warning.
- i18n keys for all seven locales.

Docs: bundle section in features/scripts.mdx (format, caps, trust model).

No new tables; imported rows are ordinary scripts rows, so the existing
abuse-signal sweep covers them by construction.

Closes #3245

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 8, 2026

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

Latest commit: b6a3ac4
Status: ✅  Deploy successful!
Preview URL: https://0e5766cf.breeze-9te.pages.dev
Branch Preview URL: https://feat-3245-script-bundle-impo.breeze-9te.pages.dev

View logs

Code-review round on #3276 raised 10 findings; this addresses the
consequential ones:

- Conflict lookups now filter is_system = false, so a bundle import in
  'new-version' mode can never match — and therefore never rewrite — a
  system-library script that shares a name with a tenant script (the
  edit PUT /scripts/:id rejects as read-only). Asserted by walking the
  generated WHERE condition in tests.
- System-scope import/preview without an orgId is rejected (400) instead
  of resolving to { orgId: null, partnerId: null } — which produced rows
  invisible to every tenant and dead conflict detection via a
  `partner_id = NULL` comparison.
- Per-entry validation: routes validate only the bundle ENVELOPE
  (version + bounded array); entries are parsed individually in the
  service, so one oversized/invalid entry fails alone (preview status
  'invalid', import errors[]) instead of rejecting the whole bundle.
- findFreeName resolves all 100 rename candidates with ONE query per
  entry instead of up to 100 sequential probes (a fully-conflicting
  200-entry bundle would otherwise issue ~20k SELECTs against the web
  client's 30s timeout).
- 'new-version' with byte-identical content is now an idempotent skip —
  re-running the same bundle no longer pads version history with no-op
  snapshots.
- findExistingByName orders by created_at so duplicate names resolve
  deterministically to the oldest row.
- Web: stale preview is cleared before re-preview/file re-selection, so
  a failed preview can't leave Import armed for a bundle or scope the
  conflict table was never computed against; export modal fetches the
  full library itself (the page state holds only page 1 of 50) with a
  200-script selection cap; partner-wide checkbox is shown only to
  partner-scope users (system scope would always 403); stray .json
  files in a picked folder are skipped instead of aborting the folder
  import, and the converter no longer emits hardcoded English prose.

Deliberately not taken (noted for follow-up): shared Dialog adoption,
downloadBlob/runAction unification for the export GET, shared enum
reuse from @breeze/shared, audit-insert batching, and an expected-state
contract between preview and import (the sibling org-import pipeline's
TOCTOU posture) — cleanups without a correctness hole in this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ToddHebebrand

Copy link
Copy Markdown
Collaborator Author

Review run: /code-review at high effort (8 finder angles: 7 review subagents + CLAUDE.md-conventions pass; 1-vote recall-biased verification; one candidate refuted — the suspected export partner-axis leak matches pre-existing GET /scripts list visibility).
Findings: 10 raised → 10 addressed in b6a3ac4; 0 outstanding correctness issues. Key fixes: conflict lookups now exclude is_system rows (a new-version import can no longer rewrite a system-library script), system-scope import without orgId is rejected instead of creating tenantless orphan rows, per-entry validation replaces wholesale bundle rejection, rename probing batched to one query per entry, idempotent new-version skip on identical content, stale-preview clearing + full-library export list + partner-scope-only checkbox + folder .json tolerance in the web UI. Below-the-line cleanups (shared Dialog, downloadBlob/runAction unification for the export GET, shared enums from @breeze/shared, audit batching, preview→import expected-state contract) are recorded in the fix commit message as follow-ups.
Tests: apps/api affected suite green single-fork (vitest run on scriptBundle service+route, scriptWrite, scripts route, bodyLimit — 102 passed / 2 pre-existing skips); apps/web green (scriptBundle lib, localeParity, translationCoverage, no-silent-mutations — 167 passed); tsc --noEmit clean for apps/api and apps/web; eslint clean on changed files. First commit's full CI: 55/55 checks passed; CI re-running on the fix commit.
Status: review-clean, awaiting maintainer merge.

@ToddHebebrand
ToddHebebrand merged commit 739e8d8 into main Aug 8, 2026
56 checks passed
@ToddHebebrand
ToddHebebrand deleted the feat/3245-script-bundle-import-export branch August 8, 2026 17:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[API][Web] No script library import/export — a migrated script library must be pasted in one script at a time

1 participant